#!/usr/bin/python

# Copyright (C) 2009 ApplianSys Ltd.
# 
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 
# MA  02110-1301, USA.
    
"""
This file contains classes for Nodes, Groups (of Nodes) and the Store class 
(which is used as a singleton).
"""

import struct
import md5
import threading
import ipaddr

from Queue import Queue

import worthyd




class Node(object):
    """
    Instances of the node class represent other nodes from which the 
    this worthd has heard.
    """
    def __init__(self, address, worthiness, valid_until, md5=""):
        """
        @param address: the address of the node
        @type address: ipaddr.IPv4 or ipaddr.IPv6
        @param worthiness: the worthiness of the node (as claimed by the node)
        @type worthiness: Integer
        @param valid_until: the time until this node should be treated as stale
        @type valid_until: Integer
        @param md5sum: the node's md5sum of the group members it know about, if this
                 equals this node's md5sum, then they agree on which nodes are
                 in the group.
        """
        self.address = address
        self.worthiness = worthiness
        self.valid_until = valid_until
        self.md5 = md5

    def __repr__(self):
        return str({'address': self.address, 
                    'worthiness': self.worthiness, 
                    'valid_until': self.valid_until,
                    'md5': self.md5,
                    })


class Group(object):
    """
    Groups holds lists of nodes in each group.
    A worthyd only has the groups it has been to told to listen for, it 
    disregards messages from groups in which it is uninterested.

    >>> group = Group("foo")
    >>> print group.group_name
    foo
    >>> n0 = Node(ipaddr.IPv4("1.2.3.4"), 1000, 100300)
    >>> n1 = Node(ipaddr.IPv4("2.3.4.5"), 900, 100400)
    >>> n2 = Node(ipaddr.IPv4("3.4.5.6"), 800, 100100)
    >>> n3 = Node(ipaddr.IPv4("4.5.6.7"), 700, None)
    >>> group.add_node(n0)
    >>> group.add_node(n1)
    >>> group.add_node(n2)
    >>> group.add_node(n3)
    >>> group.valid_nodes(100000)
    [{'valid_until': 100300, 'md5': '', 'worthiness': 1000, 'address': IPv4('1.2.3.4')}, {'valid_until': 100400, 'md5': '', 'worthiness': 900, 'address': IPv4('2.3.4.5')}, {'valid_until': 100100, 'md5': '', 'worthiness': 800, 'address': IPv4('3.4.5.6')}, {'valid_until': None, 'md5': '', 'worthiness': 700, 'address': IPv4('4.5.6.7')}]
    >>> group.rank(ipaddr.IPv4("3.4.5.6"), 100000)
    2
    >>> group.valid_nodes(100200)
    [{'valid_until': 100300, 'md5': '', 'worthiness': 1000, 'address': IPv4('1.2.3.4')}, {'valid_until': 100400, 'md5': '', 'worthiness': 900, 'address': IPv4('2.3.4.5')}, {'valid_until': None, 'md5': '', 'worthiness': 700, 'address': IPv4('4.5.6.7')}]
    >>> group.valid_nodes(100500)
    [{'valid_until': None, 'md5': '', 'worthiness': 700, 'address': IPv4('4.5.6.7')}]
    >>> group.count(100200)
    3

    

    """
    def __init__(self, group_name):
        """
        @param group_name: the name of the group
        """
        self.group_name = group_name
        self.nodes = {}
        self.starting = True    # is this yet seen as stable by this node?
        self.last_broadcast = 0  
        self.last_most_worthy = None    # who was most worthy 
                                        # last time anyone checked?
        self.last_md5 = None    # last md5


    def time_to_broadcast(self, broadcast_interval, runtime):
        """
        Is it time to broadcast?
 
        @param broadcast_interval: the interval, in ms, as specified on
                                   the command line
        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        if self.last_broadcast + broadcast_interval <= runtime:
            return True
        return False

    def add_node(self, node):
        self.nodes[node.address] = node
        

    def stable(self, runtime):
        """
        If all of the nodes which this node knows about have the same md5
        for the group members, then the group has become stable.

        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        md5s = set([])    
        for node in self.valid_nodes(runtime):
            md5s.add(node.md5)

        return len(md5s) == 1


    def most_worthy(self, runtime):
        """
        This returns the most worthy node, or None.

        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """
    
        valid_nodes = self.valid_nodes(runtime)
        if valid_nodes:
            return valid_nodes[0]
        else:
            return None


    def valid_addresses(self, runtime):
        """
        This returns a list of the address of the valid nodes.

        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        valid_nodes = self.valid_nodes(runtime)
        valid_nodes.sort(lambda x, y: cmp(x.address, y.address))
        if valid_nodes:
            return [n.address
                    for n 
                    in valid_nodes
                    ]
        else:
            return []

    
    def valid_nodes(self, runtime):
        """
        This returns a list of the valid nodes.

        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        nodes = []

        for address, node in self.nodes.items():
            if (node.valid_until >= runtime 
                or node.valid_until == None):
                nodes.append(node)

        nodes.sort(lambda x, y: cmp(y.worthiness, x.worthiness))

        return nodes


    def rank(self, address, runtime):
        """
        This returns the number of nodes of higher worthiness than the
        address 'address'.

        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        i = 0
        for node in self.valid_nodes(runtime):
            if node.address == address:
                break
            i += 1

        return i


    def expired_nodes(self, runtime):
        """
        This returns a list of the expired nodes.
 
        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        nodes = []

        for address, node in self.nodes.items():
            if (node.valid_until < runtime 
                and node.valid_until != None):
                nodes.append(node)

        nodes.sort(lambda x, y: cmp(y.worthiness, x.worthiness))

        return nodes


    def count(self, runtime):
        """
        Returns the number of valid nodes in the group.

        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        return len(self.valid_nodes(runtime))


    def md5(self, runtime):
        """
        This returns an md5 signature of the addresses of the nodes it knows about.

        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        sum = md5.new()
        nodes = self.valid_nodes(runtime)

        # we need to sort by the ip address, before computing
        # the md5sum - otherwise we encounter a 0-worthiness
        # deadlock on cluster start
        nodes.sort(lambda x, y: cmp(x.address, y.address))

        for node in nodes:
            sum.update(str(node.address))   # FIXME: this needs to be the 128bit binary rep of the address
        return sum.digest()


    def __repr__(self):
        return str({'group_name': self.group_name,
                    'nodes': self.nodes,
                    })
  

class Store(object):
    """
    The store is container for all of the groups in which a worthy is 
    interested.

    It also contains a lock. 
    """
    def __init__(self, groups={}):
        """
        @param groups: a dictionary of group_name-group key-value pairs.
                       Usually this just defaults to the empty dict.
        """
        self.groups = groups
        self.lock = threading.Lock()

    def receive_packet(self, packet, runtime):
        """
        This method is used by a listening loop to use packets to 
        update the store.

        This method is called to introduce a new worthyd packet into the
        store.

        @param packet: the packet
        @type packet: url.Packet
        @param runtime: the amount of time that worthyd has been running,
                        typically taken from Wothyd's self.runtime
        """

        assert self.groups.has_key(packet.group_name), 'invalid group'
        group = self.groups[packet.group_name]
        
        # make a node from this packet and add it to the group
        node = Node(worthyd.string_to_ipaddr(packet.sender), 
                    packet.worthiness, 
                    packet.valid_for + runtime,
                    packet.members_md5,
                    )
        group.add_node(node)

    def __repr__(self):
        return str(self.groups)


    


def _test():
    import doctest
    failures, x = doctest.testmod()
    if failures > 0:
        sys.exit(1)


if __name__ == "__main__":
    _test()

 
